SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
6.8 KB · 128 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { AdminTitle, Bool, JsonPre, Mono, StatusChip } from '@/components/admin/ui';5import { KeyValue } from '@/components/ui/key-value';6import { Unavailable } from '@/components/ui/unavailable';7import { adminApi, load, requireAdmin } from '@/lib/admin/admin-api';8import { fmtBytes, fmtDateTime, fmtInt } from '@/lib/format';9import { routes } from '@/lib/site';1011export const metadata: Metadata = { title: 'Snapshot', robots: { index: false, follow: false } };12export const dynamic = 'force-dynamic';1314/** Render the API's `diff` as change rows when it is a map/array of changes, otherwise as JSON. */15function Diff({ diff }: { diff: unknown }) {16  if (diff === null || diff === undefined) return <p className="text-xs text-ink-3">No diff (first snapshot or unchanged).</p>;17  const rows: { key: string; before: unknown; after: unknown }[] = [];18  if (Array.isArray(diff)) {19    for (const [i, it] of diff.entries()) {20      if (it && typeof it === 'object') {21        const o = it as Record<string, unknown>;22        rows.push({ key: String(o.key ?? o.path ?? o.property ?? o.field ?? i), before: o.old ?? o.before ?? o.from ?? o.old_value, after: o.new ?? o.after ?? o.to ?? o.new_value });23      }24    }25  } else if (typeof diff === 'object') {26    const o = diff as Record<string, unknown>;27    const looksLikeMap = Object.values(o).every((v) => v && typeof v === 'object' && !Array.isArray(v) && ('old' in (v as object) || 'new' in (v as object) || 'before' in (v as object) || 'after' in (v as object)));28    if (looksLikeMap) {29      for (const [k, v] of Object.entries(o)) {30        const c = v as Record<string, unknown>;31        rows.push({ key: k, before: c.old ?? c.before, after: c.new ?? c.after });32      }33    }34  }35  if (!rows.length) return <JsonPre value={diff} />;36  const show = (v: unknown) => (v === undefined || v === null ? <span className="text-ink-3">—</span> : <span className="break-all">{typeof v === 'string' ? v : JSON.stringify(v)}</span>);37  return (38    <ul className="divide-y divide-rule border-y border-rule text-xs">39      {rows.map((r) => (40        <li key={r.key} className="grid gap-x-3 gap-y-0.5 py-1.5 md:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1fr)]">41          <Mono className="text-ink">{r.key}</Mono>42          <span className="text-danger line-through decoration-danger/50">{show(r.before)}</span>43          <span className="text-positive">{show(r.after)}</span>44        </li>45      ))}46    </ul>47  );48}4950export default async function AdminSnapshotPage({ params }: { params: Promise<{ id: string }> }) {51  await requireAdmin();52  const { id } = await params;53  const res = await load(adminApi.snapshot(id));54  if (!res.ok && res.error.startsWith('404')) notFound();55  if (!res.ok) {56    return (57      <>58        <AdminTitle title="Snapshot" />59        <Unavailable what="Snapshot" reason={res.error} />60      </>61    );62  }63  const s = res.data;64  return (65    <>66      <p className="mb-2 text-xs">67        <Link href="/admin/documents" className="link">← Documents</Link> · <Link href={`/admin/documents/${encodeURIComponent(s.document_id)}`} className="link">document</Link>68      </p>69      <AdminTitle title={`Snapshot ${fmtDateTime(s.observed_at)}`} lede={<Mono>{s.id}</Mono>}>70        <Link href={`/admin/extractions/${encodeURIComponent(s.id)}`} className="inline-flex h-8 items-center border border-rule px-2.5 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">71          Extraction debugger →72        </Link>73      </AdminTitle>74      <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_22rem]">75        <div className="min-w-0 space-y-8">76          <section>77            <p className="eyebrow mb-2">Structured extraction</p>78            <JsonPre value={s.structured} maxHeight="28rem" />79          </section>80          <section>81            <p className="eyebrow mb-2">Diff vs previous snapshot</p>82            <Diff diff={s.diff} />83          </section>84          <section>85            <p className="eyebrow mb-2">86              Cleaned text excerpt {s.text_chars !== undefined && <span className="tnum text-ink-3">{fmtInt(s.text_chars)} chars{s.text_truncated ? ' · truncated at 20 kB' : ''}</span>}87            </p>88            {s.text ? (89              <pre className="scrollbar-thin max-h-[32rem] overflow-auto whitespace-pre-wrap border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink-2">{s.text}</pre>90            ) : (91              <p className="text-xs text-ink-3">{s.text_error ? `Text unavailable: ${s.text_error}` : s.has_text === false ? 'No cleaned text for this snapshot (binary or JSON document).' : 'No text.'}</p>92            )}93          </section>94        </div>95        <aside className="min-w-0 space-y-6">96          <KeyValue97            dense98            rows={[99              { key: 'url', label: 'URL', value: <a href={s.url} target="_blank" rel="noopener noreferrer" className="link break-all text-xs">{s.url}</a> },100              { key: 'final_url', label: 'Final URL', value: s.final_url && s.final_url !== s.url ? <span className="break-all text-xs">{s.final_url}</span> : undefined },101              { key: 'connector', label: 'Connector', value: <Mono>{s.connector_name ?? '—'}</Mono> },102              { key: 'doc_type', label: 'Type', raw: s.doc_type },103              { key: 'entity', label: 'Entity', value: s.entity_slug ? <Link href={routes.entity({ entity_type: s.entity_id?.split('_')[0] ?? 'model', slug: s.entity_slug })} className="link text-xs">{s.entity_name ?? s.entity_slug}</Link> : undefined },104              { key: 'http_status', label: 'HTTP', raw: s.http_status },105              { key: 'content_type', label: 'Content type', raw: s.content_type },106              { key: 'byte_size', label: 'Size', raw: fmtBytes(s.byte_size) },107              { key: 'changed', label: 'Changed', value: <Bool v={s.changed} /> },108              { key: 'processing_status', label: 'Processing', value: <StatusChip value={s.processing_status} /> },109              { key: 'parser_version', label: 'Parser', raw: s.parser_version },110              { key: 'connector_version', label: 'Connector version', raw: s.connector_version },111              { key: 'transport', label: 'Transport', raw: s.transport },112              { key: 'content_hash', label: 'Content hash', value: s.content_hash ? <Mono className="break-all">{s.content_hash}</Mono> : undefined },113              { key: 'run_id', label: 'Run', value: s.run_id ? <Mono>{s.run_id}</Mono> : undefined },114              { key: 'has_raw', label: 'Raw archived', value: <Bool v={s.has_raw} /> },115            ]}116          />117          {s.headers && Object.keys(s.headers).length > 0 && (118            <div>119              <p className="eyebrow mb-2">Response headers</p>120              <JsonPre value={s.headers} maxHeight="14rem" />121            </div>122          )}123        </aside>124      </div>125    </>126  );127}128